Rust, a new frontier

2021-02-13

The premise

In the past few years I've been slowly coming in contact with more and more Rust code. Whether it's packaging or debugging, it felt like it's about time to start learning it in more detail.

So my first project for doing this was porting over the bitte-cli from Crystal to Rust, and hoping I wouldn't completely get bogged down trying to appease the compiler.

Work is going on in the rust branch, but it might be in master by the time you're reading this and everyone is happy with the new implementation.

Given that the code was already well-typed and working with Crystal, there wasn't a lot of mental work trying to figure out /how/ it should work. By simply focusing on translation I could focus on the patterns of Rust instead.

The code is really still rather ugly and inefficient, but since I prefer learning by doing over reading books, i simply started hacking using the awesome Emacs LSP and rust-analyzer combo. It took a bit of fiddling getting the analyzer to work, but after I figured out a working nix shell, it was a pleasure to have instance feedback on type and syntax errors and a bunch of more advanced refactoring utilities.

Here's a gist of that, but check out the code for the exact versions of nixpkgs, since it seems the requirements change rather often.

pkgs.mkShell {
  RUST_SRC_PATH = pkgs.rustPlatform.rustLibSrc;
  RUST_BACKTRACE = 1;

  buildInputs = with pkgs; [
    openssl
    pkg-config
    rustc
    cargo
    (rustracer.overrideAttrs (old: { checkPhase = null; }))
    rust-analyzer
    rustfmt
    clippy
  ];
};

Now to the language itself. A lot has been written about it already, so I'm trying to keep this short.

The good

Tooling around the language is really good. About the same level as the Go ecosystem. There is rustfmt so you never have to worry about writing well-formatted code. With rust-analyzer you more or less write the code for you via automated imports, auto completion, type hints, jumping between definitions, and lots more.

The biggest difference to Go related to tooling is cargo. It's a shame every language ecosystem reinvents an ad hoc informally-specified, bug-ridden, slow implementation of half of Nix. But at least this one has a rather nice lock file with checksums.

It also helps that the Nix community has done a tremendous amount of work around packaging Rust applications, although the best option in nixpkgs now seems to be using FOD (fixed output derivation).

An alternative to this for many projects could be haskell.nix, a fork of Naersk by IOHK and able to do incremental compilation based on the hashes in the Cargo.lock itself. This gives a major boost in compilation speed, but doesn't quite work with all Rust projects out there yet.

Community adoption is quite good with Rust. There are a large number of crates available for most things I needed so far.

Option/Result types all the way. This is a rather big annoyance with Crystal, where there is often no indication of which things explode on you unless you read the docs and use ?-methods all over the place. Having someone bark at you when you forget to deal with a result (but still compiling) is IMHO the best compromise between the way Crystal and Go handle those issues.

The bad

Finding good crates is a bit hard. Squatting nice names for crates seems quite common, so the name space is polluted and many projects settle for more obscure names.

It's not immediately obvious which crates use unsafe code, although there might be some way to find out, I haven't found it yet. For a language that touts its safety, this seems like something you should display prominently next to each project.

Build speed is, like a lot of compiled languages, still rather subpar. This is most likely related to the large number of dependencies you end up with for even seemingly simple projects, caused by the almost microscopically small standard library.

Speaking of standard library. If you're coming from well-endowed languages like Ruby, Crystal, Go, or even JavaScript, Erlang and Python... you'll be in for a bit of a shock. There aren't even regular expressions in the stdlib, never mind JSON or HTTP handling. This makes it pretty hard to even start a project without immediately having to add a ton of dependencies to it. bitte-cli is currently at nearly 300 crates, and it feels like a huge liability given that you're usually supposed to take ownership of everything that goes into your result.

The ugly

Semicolons... semicolons everywhere! I know this is just my personal preference, and Nix got me a bit more accustomed to making sure they are where they need to be, but this is just not something I can understand for a language released way after many other languages have already proven that newlines are perfectly acceptable semicolons. See automatic semicolon insertion in Go) for example. Anyway, much has been said about this issue already, and I'm not bringing anything new to the table, but it's just a sad state of affairs.

The other side-effect of having a tiny stdlib is that there is no consensus around a common set of crates for basic functionality, and you end up with duplication of many parts of your stack.

For example I'm using restson for talking with the Terraform API, but for the AWS API I'm using a crate called rusoto, which uses hyper under the hood... and suddenly my whole app has to also use tokio and async/await sprinkled all over the code, even though async is nearly pointless in this context.

The strange

No post about Rust would be complete without mentioning the borrow checker. It's something that will bend your mind in novel ways, especially if you've so far mostly used garbage-collected languages.

I should probably start reading some more up on this, so far I've just been shifting things around until the compiler didn't yell at me anymore. But I recently learned that most of the things I'm doing are probably going to tank performance. Which isn't really an issue for a tiny CLI application, but will definitely have to think about it when doing something more intensive.